fix(addToCart): send forceNewEntry for items with assembly options (CHK-5575)#218
Conversation
When an item carries `options` (e.g. B2B `quoteData`), `addToCart` issues two REST calls: a clean addItem followed by addAssemblyOptions. Without a hint, the checkout engine's merge logic (`AddItemsAsync` plus the pipeline `MergeItems` step) collapses the clean addItem into any existing line for the same SKU + seller with no attachments, leaving phase 2 with no new line to attach the option to. The user-visible symptom is a silent quantity bump instead of a new distinct line. CHK-5575 (vtex/vcs.checkout#7039) adds a per-item `forceNewEntry` flag that bypasses both merge passes. This change sets `forceNewEntry: true` on cleanItems entries whose input had non-empty `options`, restoring parity with the REST behavior reported in the Kohler B2B migration. Plain adds (no `options`) are untouched and continue to merge same-SKU lines as today, guarded by the existing happy-path test. Requires vtex/vcs.checkout#7039 in the runtime engine; the flag is ignored by engines that don't recognize it, so this is forward-compatible.
|
Hi! I'm VTEX IO CI/CD Bot and I'll be helping you to publish your app! 🤖 Please select which version do you want to release:
And then you just need to merge your PR when you are ready! There is no need to create a release commit/tag.
|
|
Beep boop 🤖 I noticed you didn't make any changes at the
In order to keep track, I'll create an issue if you decide now is not a good time
|
|
Your PR has been merged! App is being published. 🚀 After the publishing process has been completed (check #vtex-io-releases) and doing A/B tests with the new version, you can deploy your release by running:
After that your app will be updated on all accounts. For more information on the deployment process check the docs. 📖 |
Summary
addToCartcollapses adds carryingoptions(assembly options / attachments such as B2BquoteData) into the quantity of a pre-existing attachment-less line for the same SKU, instead of creating a distinct line. Diego Chirineá reported this as a Kohler B2B go-live blocker; ticket 1395549 and Jefferson Benedito's investigation confirm the root cause is the two-step flow (clean addItem + addAssemblyOptions) interacting with the checkout engine's merge logic.This change sets the per-item
forceNewEntryflag (added by vtex/vcs.checkout#7039 — CHK-5575) on items that carryoptions, telling the engine to bypass both itsAddItemsAsyncmerge lookup and the pipelineMergeItemsstep. The clean addItem then produces a distinct line and the follow-up addAssemblyOptions attaches to it.Plain adds (items with no
options) are unchanged.Coordination note
Lucas Vysk mentioned in #proj-kohler / #team-b2b that he is also working on this fix. Drafting this PR so we can compare approaches; happy to close in favor of his branch if he prefers.
Backend dependency
This is the GraphQL half of the fix. It needs the engine half — vtex/vcs.checkout#7039 — running on the same environment:
release_candidateand taggedv2.577.3-beta→v2.579.0-beta. Validated live (see below) ondiegoio.vtexcommercebeta.com.br— fix works end-to-end.vcs.checkoutmainand not in any stable tag. This PR is safe to deploy to stable ahead of the backend (forceNewEntryis silently ignored by older engines — no errors, no behavioral change), but it will only actually fix the bug on stable once vtex/vcs.checkout#7039 reachesmainand a stable tag is cut. The two PRs can land independently; the engine fix is the gating one for stable.What changed
node/resolvers/items.tsaddToCart,cleanItems.mapnow returns{ ...rest, forceNewEntry: true }for items whose input had non-emptyoptions.node/clients/checkout.tsCheckout.addItemaccepts a per-item optionalforceNewEntryflag in its type signature.node/__tests__/items-mutations.test.tsoptions: []is treated as no-options.CHANGELOG.mdUnreleased→### Fixedentry.Tests (unit)
yarn testfromnode/).forceNewEntrybehavior.cleanItems === [{ id, quantity, seller }]for a plain SKU acts as an additional regression guard against accidentally settingforceNewEntryfor non-option items.mainis empty for files I touched).End-to-end validation (live, on real tenants)
Reproduced Diego's exact 4-step scenario directly against the Checkout beta engine, where CHK-5575 is live. The validation script (
/tmp/chk5575/simulate_resolver.py/simulate_kohler.py) reproduces the resolver's exact two-phase flow (PATCH /items→POST /items/{i}/assemblyOptions/{id}) with a single toggle that addsforceNewEntry: trueto phase 1 for items withoptions— the same one-line change this PR introduces innode/resolvers/items.ts.Run 1 —
diegoiotenant (teste-sabrina-160, SKU100478601, assembly keyquoteId)quoteId=123Diego-test3silently dropped,[phase2] no new parent for sku=100478601 -> assemblyOptions SKIPPEDc417311ad02549a4a80ae4deba292d60d384638d683e49579d7ac37e0b2ec619Run 2 —
kohlerdevtenant (Venza single-handle bathroom sink faucet, SKU1937466, assembly keyquoteReferenceNumber)Same SKU, schema, and assembly the Kohler PS team uses (payload sourced from the team's actual order placement:
{"isQuote":true,"quoteReferenceNumber":"…"},allowedOutdatedData:["paymentData"]).quoteReferenceNumber=Diego-Quote-Csilently dropped,[phase2] no new parent for sku=1937466 -> assemblyOptions SKIPPED2adba4fd4468421d8cf51f6bffff337974ce778790494eff801099b8ee887747Identical bug → identical fix on both tenants. The Kohler schema difference (
quoteReferenceNumbervsquoteId) doesn't change the outcome — the engine merge logic operates on SKU + seller + attachment-presence, not on attachment content.Confirming the beta-routing path
There are two ways to send traffic to the beta Checkout engine (per #team-faststore-dev / #odp-tests internal threads — Frederico Mourão, Lucas Reis, Paladino):
Cookie: vtex-commerce-env=betato any request — Janus routes regardless of which domain you hit.*.vtexcommercebeta.com.brdirectly.For IO apps that use
@vtex/api'sJanusClient(whichvtex.checkout-graphqldoes), the inboundvtex-commerce-env=betacookie setsctx.vtex.janusEnv='beta', andJanusClient.jsline 20 pickshttp://portal.vtexcommercebeta.com.bras the outbound base URL. So the cookie propagation Paladino warned about isn't an issue here — the host itself changes, no per-request cookie forwarding required.Control test proving the cookie routes correctly through this app's Janus path (same workspace, same SKU, same REST call — only the cookie differs). Run on both tenants:
forceNewEntry: truehonored?diegoiodiegoiovtex-commerce-env=beta→ beta enginekohlerdevkohlerdevvtex-commerce-env=beta→ beta engineThis independently corroborates the git-archaeology finding that CHK-5575 (
69c02503c3) is onrelease_candidate+ beta tags but not onvcs.checkoutmainyet.What this validation does not cover
vtex linkIDE round-trip on this branch: the link build fails on a pre-existing TypeScript 3.9 /@opentelemetry/api ^1.9.0.d.tssyntax mismatch innode_modules(not in any of our code; would block any link of this repo today). The two-layer coverage — unit tests asserting the exact wire payload + live engine response to that payload — already covers the full chain end-to-end. Once the OTel/builder issue is resolved, the recipe in "Test plan" below also works throughvtex link.kohlerdev(the dev/QA tenant Diego is using) but not on productionkohler. Same engine pool, same Catalog, same assembly contract — should be identical — but a final pass on a production-grade Kohler workspace from the PS team is recommended.Test plan
diegoio(Diego's SKU): 4 distinct lines after step 4kohlerdevwith real Kohler SKU1937466(Venza faucet) + realquoteReferenceNumberassembly schema: 4 distinct lines after step 4vtex-commerce-env=betacookie routes to beta Janus through this app (1 item without cookie, 2 items with)kohler.myvtex.com/admin/graphql-ideaddvtex-commerce-env=betacookie via DevTools and replay Diego's 4-step (no beta publish needed once vtex-checkout-graphql with this PR is installed in the workspace)/newman run checkout betaRisk
Lowest-impact path consistent with the chosen engine-level fix:
updateItems, subscription handling, bundle attachments, offerings, manual price.Closes the Kohler-side gap once both PRs are stable.
Additional context (added after deeper review) — client-side cart contract
After validating the fix end-to-end, we took one more pass to understand how this change interacts with the storefront's own consolidation logic, since the engine and the UI both make decisions about merging.
Conclusion: this fix aligns the engine with the long-standing storefront contract.
Where the "merge vs new line" decision actually lives today
For plain (no-attachment) adds, the decision is client-side, in
@vtex/order-items(the npm package used byvtex.minicart,vtex.add-to-cart-button,vtex.product-quantity, and the rest of the standard storefront stack). Source on GitHub wraps the npm@vtex/order-itemspackage — relevant code increateOrderItems.tsx#addItems:isSameItemmatches by SKU + seller + parentItemIndex + parentAssemblyBinding (no attachment-content comparison).So the storefront's actual decision tree is:
addToCartqty = old + newupdateItemsoptions(any assembly /quoteData/ attachment)addToCart, alwaysEngine PATCH semantics confirmed empirically against beta (matches docs): for a plain item already in the cart, the engine's
/itemsPATCH replaces the line's quantity with the request value (it doesn't accumulate). The UI never relies on engine accumulation because it pre-sums.What the engine was doing wrong
For attachment-bearing items, the UI says "always a new line" (it calls
addToCart, notupdateItems, by design). But the engine — pre-CHK-5575 — would still try to merge the bare phase-1 item into any existing same-SKU+seller line, dropping the attachments in the process. That's the bug. The engine was violating the client-side contract that@vtex/order-itemshas had for years.forceNewEntry: trueon items withoptionstells the engine: "honor what the storefront already decided — this is a new line."Standard B2B quote flow is unaffected
Worth noting separately, because the natural worry on a quote-related fix is "what about b2b-quotes?":
vtex.b2b-quotes-graphql'suseQuotemutation doesn't use per-itemquoteDataattachments at all. It (a) clears the cart, (b) groups items by${id}-${seller}and sums quantities, (c) does a single bulkPOST /items, and (d) stores the quote id as orderForm-level custom data. This fix never enters that path. The Kohler integration is a custom storefront that does use per-itemquoteDataattachments (which is why they hit this bug and standard b2b-quotes users don't).Explicit semantic change for bespoke storefronts (caveat)
The behavior change is essentially nil for any storefront built on
@vtex/order-items(which is "all standard VTEX storefronts"). The one scenario where the change is observable is:With this fix, that scenario now produces two distinct lines at quantity 1 each, instead of one line at quantity 2.
We could not find any storefront in the org that does this (b2b-quotes uses
useQuote, standard storefronts use@vtex/order-itemswhich explicitly bypasses consolidation for items withoptions). But documenting the change explicitly so any team owning a custom add-to-cart path can verify before stable rollout.Mitigations available if a regression is reported:
updateItems(withsplitItem: false) to bump quantity, matching the contract for plain items — this is the path@vtex/order-itemswould take.updateItemsinstead. We chose not to do this preemptively because it duplicates logic the storefront already owns, and goes against the documented@vtex/order-itemscontract for assembly items.cc @diego-chirinea @lvysk @felipe-romero — please flag if you know of any storefront path (Kohler or otherwise) that relies on engine-side same-attachment consolidation through
addToCart.